Анализ поведения пользователей в мобильном приложении¶

Содержание

Введение
Предобработка данных
Исследовательский анализ
Метрики
Гипотезы
Вывод

Введение¶

В нашем распоряжении данные источников установки приложения и данные логов из мобильного приложения. Требуется проанализировать данные и установить связь целевого события - просмотра контактов - с другими дейсвиями пользователей. Информация о поведении пользователей и пользовательских сценариях должна помочь улучшить пользовательский опыт, привлечь новых пользователей.

Задачи

  1. Определить сценарии действий, с помощью которых в дальнейшем заказчик сможет сегментировать пользователей.
  2. Определить, какие действия чаще совершают те пользователи, которые просматривают контакты.
  3. Посмотреть, как различается время между распространенными событиями.
  4. Узнать, какие события совершают пользователи перед целевым.
  5. Проверить основную гипотезу: одни пользователи совершают действия tips_show и tips_click, другие — только tips_show. Конверсия в просмотры контактов различается у этих двух групп.
  6. Проверить дополнительную гипотезу: пользователи, совершившие действие favorites_add лучше конвертируются в целевое действие, чем те у кого его нет.

Предобработка данных¶

In [75]:
# импортируем необходимые библиотеки
import pandas as pd
import numpy as np

import math as mth
from scipy import stats as st

import datetime as dt
from datetime import datetime, timedelta

import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
from matplotlib.ticker import AutoMinorLocator, MultipleLocator


import seaborn as sns
from plotly import graph_objects as go 

import warnings
In [2]:
# загружаем данные
sources = pd.read_csv('--')
users = pd.read_csv('--')
display(sources)
users
userId source
0 020292ab-89bc-4156-9acf-68bc2783f894 other
1 cf7eda61-9349-469f-ac27-e5b6f5ec475c yandex
2 8c356c42-3ba9-4cb6-80b8-3f868d0192c3 yandex
3 d9b06b47-0f36-419b-bbb0-3533e582a6cb other
4 f32e1e2a-3027-4693-b793-b7b3ff274439 google
... ... ...
4288 b86fe56e-f2de-4f8a-b192-cd89a37ecd41 yandex
4289 424c0ae1-3ea3-4f1e-a814-6bac73e48ab1 yandex
4290 437a4cd4-9ba9-457f-8614-d142bc48fbeb yandex
4291 c10055f0-0b47-477a-869e-d391b31fdf8f yandex
4292 d157bffc-264d-4464-8220-1cc0c42f43a9 google

4293 rows × 2 columns

Out[2]:
event.time event.name user.id
0 2019-10-07 00:00:00.431357 advert_open 020292ab-89bc-4156-9acf-68bc2783f894
1 2019-10-07 00:00:01.236320 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
2 2019-10-07 00:00:02.245341 tips_show cf7eda61-9349-469f-ac27-e5b6f5ec475c
3 2019-10-07 00:00:07.039334 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
4 2019-10-07 00:00:56.319813 advert_open cf7eda61-9349-469f-ac27-e5b6f5ec475c
... ... ... ...
74192 2019-11-03 23:53:29.534986 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9
74193 2019-11-03 23:54:00.407086 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9
74194 2019-11-03 23:56:57.041825 search_1 20850c8f-4135-4059-b13b-198d3ac59902
74195 2019-11-03 23:57:06.232189 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9
74196 2019-11-03 23:58:12.532487 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9

74197 rows × 3 columns

Будем работать с каждым датафреймом по-очереди. Для начала изменим названия столбцов на привычные.

In [3]:
# переименуем колонки
sources.columns=['user_id', 'source']
sources
Out[3]:
user_id source
0 020292ab-89bc-4156-9acf-68bc2783f894 other
1 cf7eda61-9349-469f-ac27-e5b6f5ec475c yandex
2 8c356c42-3ba9-4cb6-80b8-3f868d0192c3 yandex
3 d9b06b47-0f36-419b-bbb0-3533e582a6cb other
4 f32e1e2a-3027-4693-b793-b7b3ff274439 google
... ... ...
4288 b86fe56e-f2de-4f8a-b192-cd89a37ecd41 yandex
4289 424c0ae1-3ea3-4f1e-a814-6bac73e48ab1 yandex
4290 437a4cd4-9ba9-457f-8614-d142bc48fbeb yandex
4291 c10055f0-0b47-477a-869e-d391b31fdf8f yandex
4292 d157bffc-264d-4464-8220-1cc0c42f43a9 google

4293 rows × 2 columns

Проверка на пропуски и дубликаты¶

Проверим на пропуски и дубликаты

In [4]:
sources.duplicated().sum()
Out[4]:
0
In [5]:
sources.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 4293 entries, 0 to 4292
Data columns (total 2 columns):
 #   Column   Non-Null Count  Dtype 
---  ------   --------------  ----- 
 0   user_id  4293 non-null   object
 1   source   4293 non-null   object
dtypes: object(2)
memory usage: 67.2+ KB

Пропусков и дубликатов не обнаружено.

Проделаем те же операции со вторым датасетом.

In [6]:
# переименуем колонки
users.columns = ['event_time', 'event_name', 'user_id']
users
Out[6]:
event_time event_name user_id
0 2019-10-07 00:00:00.431357 advert_open 020292ab-89bc-4156-9acf-68bc2783f894
1 2019-10-07 00:00:01.236320 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
2 2019-10-07 00:00:02.245341 tips_show cf7eda61-9349-469f-ac27-e5b6f5ec475c
3 2019-10-07 00:00:07.039334 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
4 2019-10-07 00:00:56.319813 advert_open cf7eda61-9349-469f-ac27-e5b6f5ec475c
... ... ... ...
74192 2019-11-03 23:53:29.534986 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9
74193 2019-11-03 23:54:00.407086 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9
74194 2019-11-03 23:56:57.041825 search_1 20850c8f-4135-4059-b13b-198d3ac59902
74195 2019-11-03 23:57:06.232189 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9
74196 2019-11-03 23:58:12.532487 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9

74197 rows × 3 columns

In [7]:
users.duplicated().sum()
Out[7]:
0
In [8]:
users.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 74197 entries, 0 to 74196
Data columns (total 3 columns):
 #   Column      Non-Null Count  Dtype 
---  ------      --------------  ----- 
 0   event_time  74197 non-null  object
 1   event_name  74197 non-null  object
 2   user_id     74197 non-null  object
dtypes: object(3)
memory usage: 1.7+ MB

Пропусков и дубликатов не обнаружено.
Приведем дату к формату datetime.

In [9]:
# поменяем формат времени, округлим секунды.
users['event_time'] = pd.to_datetime(users['event_time'], format = '%Y-%m-%dT%H:%M:%S').round('1s')
users
Out[9]:
event_time event_name user_id
0 2019-10-07 00:00:00 advert_open 020292ab-89bc-4156-9acf-68bc2783f894
1 2019-10-07 00:00:01 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
2 2019-10-07 00:00:02 tips_show cf7eda61-9349-469f-ac27-e5b6f5ec475c
3 2019-10-07 00:00:07 tips_show 020292ab-89bc-4156-9acf-68bc2783f894
4 2019-10-07 00:00:56 advert_open cf7eda61-9349-469f-ac27-e5b6f5ec475c
... ... ... ...
74192 2019-11-03 23:53:30 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9
74193 2019-11-03 23:54:00 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9
74194 2019-11-03 23:56:57 search_1 20850c8f-4135-4059-b13b-198d3ac59902
74195 2019-11-03 23:57:06 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9
74196 2019-11-03 23:58:13 tips_show 28fccdf4-7b9e-42f5-bc73-439a265f20e9

74197 rows × 3 columns

У нас есть один и тот же тип события, названные по-разному: show_contact и contact_show. Поскольку это одно и то же событие, сделаем их одним.

In [10]:
def rename_event(col): # напишем функцию, которая заменит название события
    event_name = col['event_name']
    if event_name == 'show_contacts':
        return 'contacts_show'
    elif event_name == 'contacts_show':
        return 'contacts_show'
    return event_name
users['event_name'] = users.apply(rename_event, axis=1) # применим нашу функцию

Данные по разным видам поиска объединим в один. Пока что нам не важна разница между ними.

In [11]:
users['event_name'] = users['event_name'].str.replace('search_+\d', 'search', regex=True)
users['event_name'].unique() # проверим
Out[11]:
array(['advert_open', 'tips_show', 'map', 'contacts_show', 'search',
       'tips_click', 'photos_show', 'favorites_add', 'contacts_call'],
      dtype=object)

Для удобства объединим оба датасета в один.

In [12]:
data = users.merge(sources, how='outer', on='user_id')
data
Out[12]:
event_time event_name user_id source
0 2019-10-07 00:00:00 advert_open 020292ab-89bc-4156-9acf-68bc2783f894 other
1 2019-10-07 00:00:01 tips_show 020292ab-89bc-4156-9acf-68bc2783f894 other
2 2019-10-07 00:00:07 tips_show 020292ab-89bc-4156-9acf-68bc2783f894 other
3 2019-10-07 00:01:28 advert_open 020292ab-89bc-4156-9acf-68bc2783f894 other
4 2019-10-07 00:01:35 tips_show 020292ab-89bc-4156-9acf-68bc2783f894 other
... ... ... ... ...
74192 2019-11-03 23:46:47 map d157bffc-264d-4464-8220-1cc0c42f43a9 google
74193 2019-11-03 23:46:59 advert_open d157bffc-264d-4464-8220-1cc0c42f43a9 google
74194 2019-11-03 23:47:01 tips_show d157bffc-264d-4464-8220-1cc0c42f43a9 google
74195 2019-11-03 23:47:47 advert_open d157bffc-264d-4464-8220-1cc0c42f43a9 google
74196 2019-11-03 23:47:50 tips_show d157bffc-264d-4464-8220-1cc0c42f43a9 google

74197 rows × 4 columns

In [13]:
data.to_csv('mobile_data.csv', index=False) # сохраним базу в csv-файл

Проверим итоговый датасет на наличие дубликатов еще раз. Они могли появиться из-за переименований столбцов.

In [14]:
data.duplicated().sum()
Out[14]:
1190
In [15]:
data = data.drop_duplicates()
data.duplicated().sum()
Out[15]:
0

Насколько полные наши данные?¶

Какой период времени содержится в наших данных?

In [16]:
display(data['event_time'].min()) # минимальная дата
data['event_time'].max() # максимальная дата
Timestamp('2019-10-07 00:00:00')
Out[16]:
Timestamp('2019-11-03 23:58:13')

У нас данные с 7 октября 2019 по 3 ноября 2019 то есть примерно 4 недели.

Изучим пользовательскую активность за весь период.

In [17]:
# построим гистограмму распределения событий по дням
fig, ax=plt.subplots(figsize=(15, 5), dpi=150)
ax=sns.histplot(data=data, x='event_time', bins=300)
plt.title('Распределение событий по дате и времени')
plt.xlabel('Дата и время')
plt.ylabel('Количество событий')
plt.savefig('count_events.png');

Видим, что данные полные за весь исследуемый период, распределение нормальное, с пиками днем и провалами ночью.

Первоначальная обработка данных завершена, мы привели их в порядок, проверили на наличие пропусков и ошибок, поменяли формат даты-времени. Можно преступать к исследовательскому анализу.

Содержание

Исследовательский анализ¶

События¶

Посмотрим, какие вообще события содержатся в логе?

In [18]:
data['event_name'].value_counts()
Out[18]:
tips_show        39892
photos_show       9372
search            6694
advert_open       6145
contacts_show     4381
map               3760
favorites_add     1414
tips_click         811
contacts_call      538
Name: event_name, dtype: int64

Самые распространенные события - просмотр рекомендованных объявлений, скорее всего они выглядят как лента, и - просмотр фотографий выбранного объявления, далее идет раскрытие объявления и просмотр контактов. Все складывается в неплохую воронку и похоже на сценарий действий. Но он может быть не один.

Для того, чтобы посмотреть возможные сценарии, нам нужно разделить действия пользователей на сессии. За сессию будет считать 1 день.

In [19]:
# выполним сортировку по времени события, чтобы сессии собрались из стоящих рядом событий
data = data.sort_values(['user_id', 'event_time'])
# разделим сессии по дням
g = (data.groupby('user_id')['event_time'].diff() > pd.Timedelta(5, 'h')).cumsum()
# запишем результат в отдельную колонку
data['session_id'] = data.groupby(['user_id', g], sort=False).ngroup() + 1
data.head(20)
Out[19]:
event_time event_name user_id source session_id
2171 2019-10-07 13:39:46 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2172 2019-10-07 13:40:31 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2173 2019-10-07 13:41:06 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2174 2019-10-07 13:43:21 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2175 2019-10-07 13:45:31 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2176 2019-10-07 13:45:43 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2177 2019-10-07 13:46:31 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2178 2019-10-07 13:47:33 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2179 2019-10-07 13:49:42 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2180 2019-10-09 18:33:56 map 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 2
2181 2019-10-09 18:35:28 map 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 2
2182 2019-10-09 18:40:29 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 2
2183 2019-10-09 18:42:23 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 2
2184 2019-10-21 19:52:31 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 3
2185 2019-10-21 19:53:17 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 3
2186 2019-10-21 19:53:39 map 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 3
2187 2019-10-21 19:54:45 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 3
2188 2019-10-21 19:54:57 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 3
2189 2019-10-21 19:56:49 map 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 3
2190 2019-10-21 19:57:21 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 3

Отлично! У нас есть сессии, каждая из которых представляет собой сценарий действий пользователя, иначе говоря - воронку событий. Тепреь нужно понять, какие вообще сцерарии бывают, какие сценарии - наиболее распространенные и главное - какие из сценариев приводят к целевому действию, просмотру контактов.

In [20]:
# сгруппируем по id сессии и выделим последовательность уникальных событий в каждой
sessions=data.groupby('session_id').agg(event_unique=('event_name', 'unique')).reset_index()
sessions
Out[20]:
session_id event_unique
0 1 [tips_show]
1 2 [map, tips_show]
2 3 [tips_show, map]
3 4 [map, tips_show]
4 5 [search, photos_show]
... ... ...
8237 8238 [tips_show]
8238 8239 [tips_show]
8239 8240 [tips_show]
8240 8241 [tips_show, contacts_show]
8241 8242 [tips_show, contacts_show]

8242 rows × 2 columns

In [21]:
# превратим данные о событиях в строки
sessions['event_unique'] = sessions['event_unique'].apply(lambda x: str(x))
# посмотрим на самые популярные цепочки событий
sessions['event_unique'].value_counts().head(25)
Out[21]:
['tips_show']                                 1830
['photos_show']                                944
['search']                                     559
['map' 'tips_show']                            481
['search' 'photos_show']                       477
['search' 'tips_show']                         366
['tips_show' 'contacts_show']                  245
['tips_show' 'map']                            187
['map' 'advert_open' 'tips_show']              153
['tips_show' 'tips_click']                     143
['contacts_show']                              124
['photos_show' 'search']                       103
['advert_open']                                 96
['search' 'map' 'tips_show']                    96
['map']                                         91
['advert_open' 'tips_show']                     85
['map' 'tips_show' 'contacts_show']             85
['search' 'map' 'advert_open' 'tips_show']      79
['photos_show' 'contacts_show']                 75
['contacts_show' 'contacts_call']               74
['search' 'contacts_show' 'contacts_call']      48
['tips_show' 'advert_open']                     48
['contacts_show' 'tips_show']                   46
['map' 'tips_show' 'advert_open']               46
['tips_show' 'map' 'advert_open']               44
Name: event_unique, dtype: int64

Можно выделить несколько самых распространенных сценариев, приводящих к целевому действию:

  • просмотр рекомендаций, просмотр контактов
  • карта, просмотр рекомендаций, просмотр контактов
  • поиск, просмотр фото, просмотр контактов

И другие распространенные сценарии, но идущие по другому пути:

  • поиск, карта, просмотр рекомендаций
  • поиск, карта, открытие карточки объявления, просмотр рекомендаций
  • карта, открытие карточки объявления, просмотр рекомендаций

Заметим, что у нас 2 окончания цепочки - пользователи либо заканчивают сценарий просмотром контактов, либо возвращаются к прсмотру рекомендаций. Так же можно сказать, что большая часть пользователей просто "зашли посмотреть".

Построение воронки событий¶

Как видим из данных, у нас нет какой-либо одной воронки событий, но есть несколько сценариев, визуализируем их для наглядности.

In [22]:
data.head(5)
Out[22]:
event_time event_name user_id source session_id
2171 2019-10-07 13:39:46 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2172 2019-10-07 13:40:31 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2173 2019-10-07 13:41:06 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2174 2019-10-07 13:43:21 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1
2175 2019-10-07 13:45:31 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1

Воронки, ведущие к целевому событию¶

In [23]:
def funnel(events_list, users_list, title):
    fig = go.Figure(
        go.Funnel(
            y=events_list,
            x=users_list,
            textinfo = "value+percent previous", marker = {"color":
                          ['#157570', '#1CA09A', '#23C7BF', '#70E6E0']}
        )
    )
    fig.update_layout(title_text= title)
    fig.show()
    print('Конверсия в целевое действие составила {}%'.format(round(users_list[-1]/users_list[0]*100, 1)))
list_1 = data.query('event_name == "tips_show"')['user_id'].unique()
list_2 = data.query('user_id in @list_1 and event_name=="contacts_show"')['user_id'].unique()
          
users_list = [len(list_1), len(list_2)]

funnel(
    ['tips_show', 'contacts_show'], 
    users_list,
    'Просмотр рекомендаций - просмотр контактов')
Конверсия в целевое действие составила 18.4%
In [24]:
list_1 = data.query('event_name == "map"')['user_id'].unique()
list_2 = data.query('user_id in @list_1 and event_name == "tips_show"')['user_id'].unique()
list_3 = data.query('user_id in @list_2 and event_name == "contacts_show"')['user_id'].unique()          
users_list = [len(list_1), len(list_2), len(list_3)]
funnel(
    ['map','tips_show','contacts_show'], 
    users_list,
    'Просмотр рекомендаций - карта - просмотр контактов')
Конверсия в целевое действие составила 18.9%
In [25]:
list_1 = data.query('event_name == "search"')['user_id'].unique()
list_2 = data.query('user_id in @list_1 and event_name == "photos_show"')['user_id'].unique()
list_3 = data.query('user_id in @list_2 and event_name == "contacts_show"')['user_id'].unique()          
users_list = [len(list_1), len(list_2), len(list_3)]
funnel(
    ['search', 'photos_show','contacts_show'], 
    users_list,
    'Поиск - просмотр фото - просмотр контактов')
Конверсия в целевое действие составила 11.5%

Другие распространенные воронки¶

In [26]:
list_1 = data.query('event_name == "search"')['user_id'].unique()
list_2 = data.query('user_id in @list_1 and event_name == "map"')['user_id'].unique()
list_3 = data.query('user_id in @list_2 and event_name == "tips_show"')['user_id'].unique()          
users_list = [len(list_1), len(list_2), len(list_3)]
funnel(
    ['search', 'map','tips_show'], 
    users_list,
    'Поиск - карта - просмотр рекомендаций')
Конверсия в целевое действие составила 25.5%
In [27]:
list_1 = data.query('event_name == "search"')['user_id'].unique()
list_2 = data.query('user_id in @list_1 and event_name == "map"')['user_id'].unique()
list_3 = data.query('user_id in @list_2 and event_name == "advert_open"')['user_id'].unique()
list_4 = data.query('user_id in @list_3 and event_name == "tips_show"')['user_id'].unique() 

users_list = [len(list_1), len(list_2), len(list_3), len(list_4)]
funnel(
    ['search', 'map', 'advert_open','tips_show'], 
    users_list,
    'Поиск - карта - открытие карточки объявления - просмотр рекомендаций')
Конверсия в целевое действие составила 13.2%

Эта воронка очень похожа на предыдущую, но тут всего 13.2% вернулись к просмотру рекомендаций, а на прошлом сценарии, когда не было дополнительного открытия карточки объявления - 25.5% вернулись к дальшешему просмотру объявлений.

In [28]:
list_1 = data.query('event_name == "map"')['user_id'].unique()
list_2 = data.query('user_id in @list_1 and event_name == "advert_open"')['user_id'].unique()
list_3 = data.query('user_id in @list_2 and event_name == "tips_show"')['user_id'].unique()

users_list = [len(list_1), len(list_2), len(list_3)]
funnel(
    ['map', 'advert_open','tips_show'], 
    users_list,
    'Карта - открытие карточки объявления - просмотр рекомендаций')
Конверсия в целевое действие составила 32.4%

Мы выяснили что пользователи идут по 2 основным сценарием, они либо просмотривают объявления, изучают рекомендации и переходят к просмотру контактов, либо делают то же самое и возвращаются к дальнейшему просмотру объявлений.

Подсчет времени между распространенными событиями¶

Посмотрим, сколько времени проходит между событиями пользователей внутри сессий. Это поможет нам определить, нет ли "застревания" на каком-либо этапе.

Чтобы понять, какие признаки рассматривать, построим тепловую карту корреляций.

In [29]:
# соберем признаки в таблицу
events = data.pivot_table(columns='event_name', index='session_id', values = 'user_id', aggfunc='count')
events
Out[29]:
event_name advert_open contacts_call contacts_show favorites_add map photos_show search tips_click tips_show
session_id
1 NaN NaN NaN NaN NaN NaN NaN NaN 9.0
2 NaN NaN NaN NaN 2.0 NaN NaN NaN 2.0
3 NaN NaN NaN NaN 2.0 NaN NaN NaN 12.0
4 NaN NaN NaN NaN 2.0 NaN NaN NaN 6.0
5 NaN NaN NaN NaN NaN 5.0 4.0 NaN NaN
... ... ... ... ... ... ... ... ... ...
8238 NaN NaN NaN NaN NaN NaN NaN NaN 1.0
8239 NaN NaN NaN NaN NaN NaN NaN NaN 2.0
8240 NaN NaN NaN NaN NaN NaN NaN NaN 1.0
8241 NaN NaN 2.0 NaN NaN NaN NaN NaN 4.0
8242 NaN NaN 7.0 NaN NaN NaN NaN NaN 22.0

8242 rows × 9 columns

In [30]:
events = round(events.corr(), 2)

fig, ax = plt.subplots(figsize=(10, 7), dpi=90)

# нарисуем тепловую карту с подписями для матрицы корреляций
ax = sns.heatmap(events, annot=True)
plt.title('Тепловая карта корреляции признаков');
plt.savefig('heatmap_corr.png')

100% корреляция тут не так интересна - скорее всего фото показывается автоматически при нажатии на объявление.
Другие наиболее коррелирующие события:

  • просмотр карты - добавление в избранное,
  • просмотр карты - просмотр фото (тут они тоже скорее всего отображаются автоматически)
  • открытие объявления - добавление в избранное
  • показ рекомендаций - открытие объявления

Посмотрим на время между этими событиями, скорее всего, если между ними проходит большое количество времени - крреляция не однозначна.

Напишем функцию, которая будет выводить таблицу с временем совершения первого и второго события.

In [31]:
def time_between(e_1, e_2):
    # выделим сессии, в которых встречаются оба события
    event_1 = data[data['event_name'] == e_1] # отделим событие 1
    list_1 = event_1['session_id'].unique()
    event_2 = data[data['event_name'] == e_2] # отделим событие '2
    list_2 = event_2['session_id'].unique()

    events_list = set(list(set(list_1) & set(list_2))) # найдем пересечение событий
    event1_event2 = data[data['session_id'].isin(events_list)] # отфильтруем по списку

    # а теперь еще удалим лишние события из датасета, они не нужны нам в исследовании
    event1_event2 = event1_event2.query('event_name == @e_1 | event_name == @e_2')
    
    # найдем минимальную дату первого события внутри сессии и минимальную дату второго события
    event1_event2 = pd.pivot_table(event1_event2, values ='event_time', index ='session_id',columns ='event_name', aggfunc='min')
    
    # обработаем датасет, удалив из него события в обратной последовательности.
    # соберем разницу в отдельную колонку
    event1_event2['diff'] = event1_event2[e_2]-event1_event2[e_1] 
    # удалим лишние строки
    event1_event2.drop(event1_event2[event1_event2[e_1] > event1_event2[e_2]].index, inplace = True)
    # переведем интервал в секунды
    event1_event2['diff'] = event1_event2['diff'].apply(lambda x: x.total_seconds())
    return event1_event2

Карта - добавление в избранное map - favorites_add¶

Теперь можем просто воспользоваться нашей функцией и найти среднее время между интересцющими нас событиями в минутах

In [32]:
map_favorites = time_between('map', 'favorites_add')

print('Медианное время между просмотром карты и добавлением в избранное: {} минут'.format(round(map_favorites['diff'] / 60, 1).median()))
Медианное время между просмотром карты и добавлением в избранное: 11.25 минут
In [33]:
# построим диаграмму боксплот
sns.boxplot(data=map_favorites, x= 'diff')
plt.title('Распределение расстояния во времени между событиями')
plt.xlabel('Секунды');

В данные попали выбросы, которые искажают картину и не дают построить диаграмму распределения. Посмотрим, где они и отбросим хвост.

Проверим персентили, чтобы понять, сколько данных мы можем отбросить.

In [34]:
# найдем 95 персентиль
perc_95 = np.percentile(map_favorites['diff'], 95)

print('Меньше 5% данных составляют время {} минут'.format(round(perc_95/60,1)))
Меньше 5% данных составляют время 171.4 минут

5% выбросов - это расстояние между событиями в более чем 2,8 часа. Отбросим этот хвост.

In [35]:
map_favorites = map_favorites.query('diff < @perc_95')
In [36]:
# построим диаграмму боксплот
fig, ax=plt.subplots(figsize=(15, 3), dpi=150)

ax = sns.boxplot(data=map_favorites, x= 'diff')
quantiles = np.quantile(map_favorites['diff'], np.array([0.00, 0.25, 0.50, 0.75, 1]))
ax.set_xticks(quantiles)

plt.title('Распределение расстояния между просмотром карты и добавлением в избранное')
plt.xlabel('Секунды');

Разброс значений находится в промежутке от 5 до 25 минут.

In [37]:
print('Медианное время между просмотром карты и добавлением в избранное: {} минут'.format(round(map_favorites['diff'] / 60, 1).median()))
Медианное время между просмотром карты и добавлением в избранное: 11.2 минут

Открытие объявления - добавление в избранное advert_open - favorites_add¶

In [38]:
open_favorites = time_between('advert_open', 'favorites_add')

print('Медианное время между открытием объявления и добавлением в избранное: {} минут'.format(round(open_favorites['diff'].median()/ 60, 1)))
Медианное время между открытием объявления и добавлением в избранное: 14.7 минут
In [39]:
print('Медианное время между открытием объявления и добавлением в избранное: {} минут'.format(round(open_favorites['diff'].median()/ 60, 1)))
Медианное время между открытием объявления и добавлением в избранное: 14.7 минут
In [40]:
# найдем 95 персентиль
perc_95 = np.percentile(open_favorites['diff'], 95)
print('Меньше 5% данных составляют время {} минут'.format(round(perc_95/60,1)))
Меньше 5% данных составляют время 131.6 минут

5% выбросов - это расстояние между событиями в более чем полтора часа. Отбросим этот хвост.

In [41]:
open_favorites = open_favorites.query('diff < @perc_95')
In [42]:
# построим диаграмму боксплот
fig, ax=plt.subplots(figsize=(15, 3), dpi=150)

ax = sns.boxplot(data=open_favorites, x= 'diff')
quantiles = np.quantile(open_favorites['diff'], np.array([0.00, 0.25, 0.50, 0.75, 1]))
ax.set_xticks(quantiles)

plt.title('Распределение расстояния между открытием объявления и добавлением в избранное')
plt.xlabel('Секунды');

Разброс значений находится в промежутке от 8 до 54 минут.

In [43]:
print('Медианное время между открытием объявления и добавлением в избранное: {} минут'.format(round(open_favorites['diff'].median()/ 60, 1)))
Медианное время между открытием объявления и добавлением в избранное: 13.9 минут

Просмотр фото - просмотр контактов photos_show - contacts_show¶

In [44]:
photos_contacts = time_between('photos_show', 'contacts_show')

print('Медианное время между просмотром фото объявления и просмотром контактов: {} минуты'.format(round(photos_contacts['diff'].median() / 60, 1)))
Медианное время между просмотром фото объявления и просмотром контактов: 7.9 минуты
In [45]:
# найдем 95 персентиль
perc_95 = np.percentile(photos_contacts['diff'], 95)
print('Меньше 5% данных составляют время {} часа'.format(round(perc_95/3600,1)))
Меньше 5% данных составляют время 3.7 часа

5% выбросов - это расстояние между событиями в более чем полтора часа. Отбросим этот хвост.

In [46]:
photos_contacts = photos_contacts.query('diff < @perc_95')
In [47]:
# построим диаграмму боксплот
fig, ax=plt.subplots(figsize=(15, 3), dpi=150)

ax = sns.boxplot(data=photos_contacts, x= 'diff', showfliers=False)
quantiles = np.quantile(photos_contacts['diff'], np.array([0.00, 0.25, 0.50, 0.75]))
ax.set_xticks(quantiles)

plt.title('Распределение расстояния между просмотром фото объявления и просмотром контактов')
plt.xlabel('Секунды');

Разброс значений находится в промежутке от 2,5 до 38 минут.

In [48]:
print('Медианное время между просмотром фото объявления и просмотром контактов: {} минут'.format(round(photos_contacts['diff'].median() / 60, 1)))
Медианное время между просмотром фото объявления и просмотром контактов: 7.0 минут

Карта - добавление в избранное map - tips_show¶

In [49]:
map_contacts = time_between('map', 'tips_show')

print('Медианное время между просмотром карты и просмотром рекомендаций: {} минуты'.format(round(map_contacts['diff'].median() / 60, 1)))
Медианное время между просмотром карты и просмотром рекомендаций: 1.7 минуты
In [50]:
# найдем 95 персентиль
perc_95 = np.percentile(map_contacts['diff'], 95)
print('Меньше 5% данных составляют время {} минуты'.format(round(perc_95/60,1)))
Меньше 5% данных составляют время 13.6 минуты

5% выбросов - это расстояние между событиями в более чем полтора часа. Отбросим этот хвост.

In [51]:
map_contacts = map_contacts.query('diff < @perc_95')
In [52]:
# построим диаграмму боксплот
fig, ax=plt.subplots(figsize=(15, 3), dpi=150)
ax = sns.boxplot(data=map_contacts, x= 'diff')
quantiles = np.quantile(map_contacts['diff'], np.array([0.00, 0.25, 0.50, 0.75]))
ax.set_xticks(quantiles)

plt.title('Распределение расстояния между просмотром карты и просмотром рекомендаций')
plt.xlabel('Секунды');

Разброс значений находится в промежутке от 1 до 3 минут.

In [53]:
print('Медианное время между просмотром карты и просмотром рекомендаций: {} минуты'.format(round(map_contacts['diff'].median() / 60, 1)))
Медианное время между просмотром карты и просмотром рекомендаций: 1.6 минуты

У нас получились следующие данные:

  • Медианное время между просмотром карты и добавлением в избранное: 11.25 минут.
  • Медианное время между открытием объявления и добавлением в избранное: 13.9 минут.
  • Медианное время между просмотром фото объявления и просмотром контактов: 7.0 минут.
  • Медианное время между просмотром карты и просмотром рекомендаций: 1.6 минуты.

Можно предположить, что алгоритм приложения показывает рекомендации рядом при просмотре карты. Ближе всего к целевому действию просмотр фото объявления, среди тех кто приходит по этому пути, время между этими событиями занимает меньше 10 минут. Время между открытием объявления и добавлением в избранное чуть больше, что говорит о том, что между ними возможно есть еще много других действий, человек смотрит и выбирает и после этого добавляет понравившиеся объявления в избранное.

Сложно сделать вывод, что в приложении есть какие-то технические сложности, которые приводят к тому, что пользователи не доходят до целевого действия. А вот бы все мы ходили по магазинам только тогда когда нам действительно что-то нужно.

Содержание

Разброс значений находится в промежутке от 8 до 54 минут.

Подсчет метрик¶

Какая доля пользователей совершила целевое действие¶

Сколько всего пользователей в логе?¶

In [54]:
print('В логе', data['user_id'].nunique(), 'пользователь')
В логе 4293 пользователь

Сколько в среднем событий приходится на пользователя? ( в разрезе сессий)¶

In [55]:
# посчитаем количество событий на пользователя и возьмем среднее
event_mean = data.groupby(['user_id','session_id'], as_index=False).agg({'event_name': 'count'})
event_nunique = data.groupby(['user_id','session_id'], as_index=False).agg({'event_name': 'nunique'})
display(event_mean['event_name'].describe())
display(event_nunique['event_name'].describe())
print('В среднем на пользователя приходится', round(event_mean['event_name'].mean()), 'событий')
print('В среднем на пользователя приходится', round(event_nunique['event_name'].mean()), 'уникальных события')
count    8242.000000
mean        8.857923
std        12.045331
min         1.000000
25%         2.000000
50%         5.000000
75%        10.000000
max       384.000000
Name: event_name, dtype: float64
count    8242.000000
mean        1.854404
std         0.961792
min         1.000000
25%         1.000000
50%         2.000000
75%         2.000000
max         6.000000
Name: event_name, dtype: float64
В среднем на пользователя приходится 9 событий
В среднем на пользователя приходится 2 уникальных события
In [56]:
event_mean['event_name'].hist(bins=100)
plt.title('Распределение количества событий на одного пользователя');

Кажется, что в среднем пользователь совершает 9 уникальных событий, но если взглянуть на распределение, уидим, что в данных есть большие выборосы в расчете событий на одного пользователя - в основном они совершают от 2 до 5 событий, а события на сессию распределены более равномерно, о чем нам говорит величина стандартного отклонения меньше 1.

Целевое действие¶

In [57]:
# сколько пользователей совершили целевое действие - просмотр контактов
goal_users = data.query('event_name == "contacts_show"')['user_id'].nunique()
print('{} пользователей просмотрели контакты.'.format(goal_users))
print('Что составляет {}%'.format(round(goal_users/data['user_id'].nunique()*100, 2)))
981 пользователей просмотрели контакты.
Что составляет 22.85%

DAU/WAU¶

Конверсию будем считать так же в разрезе сессий. Поскольку сессии разделены по дням, мы выполним группировку по дням и вычислим число уникальных пользователей за каждый день - это наша метрика DAU.

In [58]:
# выполним группировку по дням и посчитаем количество уникальных пользователей
dau = data.groupby([pd.Grouper(key='event_time',freq="D")]).agg({'user_id': 'nunique'}).reset_index()
print('Среднее количество посетителей сайта в день: {} человек'.format(round(dau['user_id'].mean())))
Среднее количество посетителей сайта в день: 279 человек
In [59]:
dau1 = dau.copy()
dau1['event_time'] = dau1['event_time'].astype(str)
f, ax = plt.subplots(figsize=(15, 5), dpi=150)
ax = sns.barplot(data = dau1, x = 'user_id', y = 'event_time', color='#1CA09A')
plt.title('Дневная активность пользователей(DAU)')
plt.ylabel('')
plt.xlabel('количество пользователей')
plt.savefig('dau.png');

Так же посчитаем недельную активность.

In [60]:
# выполним группировку по неделям и посчитаем количество уникальных пользователей
wau = data.groupby([pd.Grouper(key='event_time',freq="W")]).agg({'user_id': 'nunique'}).reset_index()

print('Среднее количество посетителей сайта в неделю: {} человека'.format(round(wau['user_id'].mean())))
Среднее количество посетителей сайта в неделю: 1382 человека
In [61]:
wau1 = wau.copy()
wau1['event_time'] = wau1['event_time'].apply(lambda x: x.isocalendar()[1])
f, ax = plt.subplots(figsize=(15, 5), dpi=150)
ax = sns.barplot(data = wau1, y = 'user_id', x = 'event_time', color='#1CA09A')
plt.title('Недельная активность пользователей(WAU)')
plt.xlabel('')
plt.ylabel('количество пользователей')
plt.savefig('wau.png');

Конверсия в целевое действие¶

А теперь посмотрим, сколько из этих пользователей совершили целевое действие.

In [62]:
# выберем только интересующие нас событие
goal = data.query('event_name == "contacts_show"')

# выполним группировку по дням и посчитаем количество уникальных пользователей
d_goal = goal.groupby([pd.Grouper(key='event_time',freq="D")]).agg({'user_id': 'nunique'}).reset_index()
print('В среднем конверсия в целевое действие составила {}%'.format(round((d_goal['user_id']/dau['user_id']*100).mean(), 2)))
В среднем конверсия в целевое действие составила 18.35%

Коэффициент удержания (Retention Rate)¶

Возьмем первую дату посещения и посмотрим на график динамики удержания.

In [63]:
# соберем самые ранние даты совершения события для каждого пользователя 
min_time = data.groupby('user_id').agg(min_time =('event_time', 'min')).reset_index()
min_time['min_time'] = min_time['min_time'].dt.date # оставим только дату, без времени
min_time
data = data.merge(min_time, on='user_id')
data
Out[63]:
event_time event_name user_id source session_id min_time
0 2019-10-07 13:39:46 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1 2019-10-07
1 2019-10-07 13:40:31 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1 2019-10-07
2 2019-10-07 13:41:06 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1 2019-10-07
3 2019-10-07 13:43:21 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1 2019-10-07
4 2019-10-07 13:45:31 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1 2019-10-07
... ... ... ... ... ... ...
73002 2019-11-03 15:51:24 tips_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b google 8242 2019-10-12
73003 2019-11-03 15:51:58 contacts_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b google 8242 2019-10-12
73004 2019-11-03 16:07:41 tips_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b google 8242 2019-10-12
73005 2019-11-03 16:08:18 tips_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b google 8242 2019-10-12
73006 2019-11-03 16:08:25 tips_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b google 8242 2019-10-12

73007 rows × 6 columns

In [64]:
# определим доступный интервал
min_analysis_date = data['event_time'].min()
observation_date = data['event_time'].max()# ваш код здесь

# зададим горизонт анализа и посчитаем максимальную дату для исследования
analysis_horizon = 14 # ваш код здесь
max_analysis_date =  observation_date - timedelta(days=analysis_horizon - 1)# ваш код здесь

# рассчитаем размеры когорт
report = (
    data.query('@max_analysis_date >= event_time >= @min_analysis_date')  
    .groupby('min_time')
    .agg({'user_id': 'nunique'}) 
)
# построим график подключения пользователей к приложению
report.plot(figsize=(15,5), grid=True)  
plt.title('Динамика прихода пользователей')  
plt.xlabel('Дата первого посещения')  
plt.xticks(rotation=45)  
plt.ylabel('Размер когорты')   
plt.show()

Вычислим lifetime каждого пользователя.

Добавим в таблицу начало сессии, чтобы посчитать удержание.

In [65]:
session_start = data.groupby(['user_id', 'session_id']).agg(session_start =('event_time', 'min')).reset_index()
data = data.merge(session_start[['session_id', 'session_start']], on='session_id')
data['session_start'] = data['session_start'].dt.date
data
Out[65]:
event_time event_name user_id source session_id min_time session_start
0 2019-10-07 13:39:46 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1 2019-10-07 2019-10-07
1 2019-10-07 13:40:31 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1 2019-10-07 2019-10-07
2 2019-10-07 13:41:06 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1 2019-10-07 2019-10-07
3 2019-10-07 13:43:21 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1 2019-10-07 2019-10-07
4 2019-10-07 13:45:31 tips_show 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 other 1 2019-10-07 2019-10-07
... ... ... ... ... ... ... ...
73002 2019-11-03 15:51:24 tips_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b google 8242 2019-10-12 2019-11-03
73003 2019-11-03 15:51:58 contacts_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b google 8242 2019-10-12 2019-11-03
73004 2019-11-03 16:07:41 tips_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b google 8242 2019-10-12 2019-11-03
73005 2019-11-03 16:08:18 tips_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b google 8242 2019-10-12 2019-11-03
73006 2019-11-03 16:08:25 tips_show fffb9e79-b927-4dbb-9b48-7fd09b23a62b google 8242 2019-10-12 2019-11-03

73007 rows × 7 columns

In [77]:
# напишем функцию для вычисления удержания
def get_retention(
    df, observation_date, horizon_days, ignore_horizon = False
):

    # исключаем пользователей, не «доживших» до горизонта анализа
    last_suitable_acquisition_date = observation_date
    result_raw = df.query('min_time <= @last_suitable_acquisition_date')
    
    # собираем «сырые» данные для расчёта удержания
    
    result_raw['lifetime'] = (
        result_raw['session_start'] - result_raw['min_time']
    ).dt.days

    # рассчитываем удержание
    result_grouped = result_raw.pivot_table(
        index=['min_time'], columns='lifetime', values='user_id', aggfunc='nunique'
    )
    cohort_sizes = (
        result_raw.groupby('min_time')
        .agg({'user_id': 'nunique'})
        .rename(columns={'user_id': 'cohort_size'})
    )
    # объединяем размеры когорт и таблицу удержания

    result_grouped = cohort_sizes.merge(
        result_grouped, on='min_time', how='left'
    ).fillna(0)

    result_grouped = result_grouped.div(
    result_grouped['cohort_size'], axis=0
    )

    # исключаем все лайфтаймы, превышающие горизонт анализа
    result_grouped = result_grouped[
        ['cohort_size'] + list(range(horizon_days))
    ]

    # восстанавливаем столбец с размерами когорт
    result_grouped['cohort_size'] = cohort_sizes

    # возвращаем таблицу удержания и сырые данные
    return result_raw, result_grouped

# задаём момент и горизонт анализа данных
observation_date = data['event_time'].max()
horizon_days = 14

# построим таблицу удержания
warnings.filterwarnings('ignore')
retention_raw, retention = get_retention(data, observation_date, analysis_horizon)# ваш код здесь

retention
Out[77]:
cohort_size 0 1 2 3 4 5 6 7 8 9 10 11 12 13
min_time
2019-10-07 204 1.0 0.176471 0.102941 0.107843 0.063725 0.053922 0.039216 0.078431 0.063725 0.049020 0.068627 0.034314 0.019608 0.034314
2019-10-08 167 1.0 0.167665 0.119760 0.101796 0.035928 0.035928 0.065868 0.047904 0.065868 0.053892 0.059880 0.035928 0.017964 0.041916
2019-10-09 176 1.0 0.090909 0.085227 0.056818 0.056818 0.079545 0.056818 0.056818 0.056818 0.051136 0.022727 0.022727 0.039773 0.039773
2019-10-10 174 1.0 0.132184 0.074713 0.097701 0.074713 0.091954 0.063218 0.074713 0.063218 0.057471 0.040230 0.045977 0.045977 0.057471
2019-10-11 136 1.0 0.073529 0.073529 0.132353 0.095588 0.066176 0.058824 0.073529 0.022059 0.000000 0.051471 0.036765 0.044118 0.044118
2019-10-12 125 1.0 0.184000 0.120000 0.104000 0.064000 0.072000 0.104000 0.080000 0.072000 0.064000 0.064000 0.056000 0.048000 0.064000
2019-10-13 148 1.0 0.162162 0.087838 0.054054 0.074324 0.060811 0.040541 0.081081 0.047297 0.060811 0.013514 0.040541 0.020270 0.060811
2019-10-14 185 1.0 0.167568 0.086486 0.097297 0.097297 0.070270 0.048649 0.054054 0.054054 0.048649 0.037838 0.027027 0.037838 0.037838
2019-10-15 186 1.0 0.139785 0.096774 0.091398 0.059140 0.053763 0.064516 0.064516 0.086022 0.032258 0.043011 0.021505 0.026882 0.037634
2019-10-16 166 1.0 0.150602 0.102410 0.048193 0.024096 0.060241 0.048193 0.054217 0.060241 0.048193 0.030120 0.024096 0.036145 0.054217
2019-10-17 170 1.0 0.164706 0.111765 0.141176 0.070588 0.100000 0.082353 0.052941 0.052941 0.035294 0.029412 0.070588 0.047059 0.041176
2019-10-18 156 1.0 0.102564 0.076923 0.121795 0.057692 0.044872 0.083333 0.076923 0.064103 0.044872 0.032051 0.032051 0.038462 0.044872
2019-10-19 159 1.0 0.144654 0.125786 0.081761 0.031447 0.050314 0.056604 0.069182 0.056604 0.025157 0.044025 0.044025 0.025157 0.018868
2019-10-20 144 1.0 0.152778 0.076389 0.076389 0.048611 0.076389 0.055556 0.055556 0.062500 0.027778 0.027778 0.020833 0.020833 0.020833
2019-10-21 161 1.0 0.099379 0.105590 0.068323 0.068323 0.043478 0.031056 0.099379 0.043478 0.031056 0.031056 0.037267 0.006211 0.012422
2019-10-22 157 1.0 0.140127 0.108280 0.063694 0.057325 0.031847 0.031847 0.082803 0.057325 0.057325 0.063694 0.050955 0.057325 0.000000
2019-10-23 189 1.0 0.137566 0.074074 0.084656 0.052910 0.063492 0.042328 0.058201 0.042328 0.026455 0.021164 0.015873 0.000000 0.000000
2019-10-24 162 1.0 0.123457 0.086420 0.074074 0.043210 0.074074 0.067901 0.037037 0.030864 0.037037 0.030864 0.000000 0.000000 0.000000
2019-10-25 147 1.0 0.102041 0.068027 0.129252 0.108844 0.061224 0.061224 0.081633 0.040816 0.054422 0.000000 0.000000 0.000000 0.000000
2019-10-26 139 1.0 0.179856 0.100719 0.064748 0.064748 0.100719 0.028777 0.064748 0.050360 0.000000 0.000000 0.000000 0.000000 0.000000
2019-10-27 139 1.0 0.165468 0.064748 0.086331 0.064748 0.064748 0.079137 0.064748 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
2019-10-28 158 1.0 0.132911 0.094937 0.082278 0.050633 0.031646 0.025316 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
2019-10-29 135 1.0 0.192593 0.111111 0.118519 0.081481 0.044444 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
2019-10-30 134 1.0 0.208955 0.067164 0.052239 0.074627 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
2019-10-31 132 1.0 0.113636 0.068182 0.045455 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
2019-11-01 112 1.0 0.116071 0.053571 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
2019-11-02 97 1.0 0.144330 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000
2019-11-03 135 1.0 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000

Построим график удержания.

In [67]:
# строим хитмэп без нулевого лайфтайма

plt.figure(figsize=(15, 6), dpi=150)  # задаём размер графика
sns.heatmap(
    retention.drop(columns=['cohort_size', 0]),  # удаляем лишние столбцы
    annot=True,  # включаем подписи
    fmt='.2%',  # переводим значения в проценты
)
plt.title('Тепловая карта удержания')  # название графика
plt.savefig('heatmap_retention.png')
plt.ylabel('');

Видно, что всплески активности приходятся на каждые выходные, но невозможно выделить какие либо даты, когда пользователи лучше удерживались - все данные +- ровные и удержание стабильно низкое.

Промужеточные выводы

  • Воронка событий, в которой присутствует карта с большей вероятностью приводит к целевому событию, чем просто поиск и просмотр объявлений, значит людям важно искать объявления поблизости.
  • На конверсию в целевое действие так же влияет добавление в избранное.
  • Судя по времени между между событиями, рекомендации показывают сразу после просмотра карты. Видимо это эффективный сценарий, приводящий к целевому действию.
  • Люди чаще регистрируются и используют приложение по-выходным, но на удержание это не влияет.
  • Среднее количество посетителей сайта в день: 279 человек.
  • Среднее количество посетителей сайта в неделю: 1382 человека.

Содержание

Проверка гипотез¶

Гипотеза 1¶

$H^0$ Конверсия в целевое действие из tips_show и tips_click не отличается от конверсии из только tips_show. $H^1$ - конверсии значимо отличаются . Проверим при помощи z-теста по долям.

Найдем конверсию из tips_show в целевое contacts_show. При этом надо удалить из числа этих пользователей тех, кто совершил еще и tips_click.

In [68]:
# напишем функцию, которая будет проверять равернство групп

def equal(successes, trials):
    alpha = .05 # критический уровень статистической значимости
  
    # пропорция успехов в первой группе:
    p1 = successes[0]/trials[0]
    # пропорция успехов во второй группе:
    p2 = successes[1]/trials[1]

    # пропорция успехов в комбинированном датасете:
    p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
    # разница пропорций в датасетах
    difference = p1 - p2 

    # считаем статистику в ст.отклонениях стандартного нормального распределения
    z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))

    # задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
    distr = st.norm(0, 1)  

    p_value = (1 - distr.cdf(abs(z_value))) * 2

    print('p-значение: ', p_value)

    if p_value < alpha:
        print('Отвергаем нулевую гипотезу: между долями есть значимая разница.')
    else:
        print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными.')
        
In [69]:
# выделим пользователей, которые совершили tips_show, это будет первое значение trial
tips_users = data.query('event_name == "tips_show"')['user_id'].unique()
display(len(tips_users))
# а теперь те, кто совершил tips_show и вместе с ним еще и целевое contacts_show, это будет первое значение sucess

# из их числа нужно вычесть тех, кто совершил tips_click
click_users = data.query('event_name == "tips_click"')['user_id'].unique()
tips_contacts_users = data.query('user_id in @tips_users and user_id not in @click_users and event_name == "contacts_show"')['user_id'].unique()

display(len(tips_contacts_users))
print('Конверсия в целевое действие составила {}%'.format(round(len(tips_contacts_users)/len(tips_users)*100, 1)))
2801
425
Конверсия в целевое действие составила 15.2%
In [70]:
# выделим пользователей, которые совершили tips_show и tips_click, это будет второе значение trial
show_click_users = data.query('user_id in @tips_users and event_name == "tips_click"')['user_id'].unique()
display(len(show_click_users))

# а теперь те, кто совершил tips_show и вместе с ним еще и целевое contacts_show, это будет второе значение sucess
contacts_show_click_users = data.query('user_id in @show_click_users and event_name == "contacts_show"')['user_id'].unique()
display(len(contacts_show_click_users)) 
print('Конверсия в целевое действие составила {}%'.format(round(len(contacts_show_click_users)/len(show_click_users)*100, 1)))
297
91
Конверсия в целевое действие составила 30.6%
In [71]:
# применим нашу функцию, чтобы сравнить группы
successes = np.array([len(tips_contacts_users), len(contacts_show_click_users)])
trials = np.array([len(tips_users), len(show_click_users)])
equal(successes, trials)
p-значение:  1.0283995877102825e-11
Отвергаем нулевую гипотезу: между долями есть значимая разница.

На основании статистического теста мы можем сказать, что конверсия в целевое действие из tips_show и tips_click отличается от конверсии только из tips_show.

Гипотеза 2¶

Пользователи, совершившие действие favorites_add лучше конвертируются в целевое действие, чем те у кого его нет.
$H^0$ - конверсия в целевое действие из favorites_add такая же как у пользователей, не совершивших это событие.
$H^1$ - конверсия отличается. Для проверки искользуем тот же тест.

In [72]:
# выделим пользователей, которые совершили favorites_add, это будет первое значение trial
fave_users = data.query('event_name == "favorites_add"')['user_id'].unique()
display(len(fave_users))
# а теперь те, кто совершил tips_show и вместе с ним еще и целевое contacts_show, это будет первое значение sucess

fave_contacts_users = data.query('user_id in @fave_users and event_name == "contacts_show"')['user_id'].unique()
display(len(fave_contacts_users)) 
print('Конверсия в целевое действие составила {}%'.format(round(len(fave_contacts_users)/len(fave_users)*100, 1)))
351
136
Конверсия в целевое действие составила 38.7%

А теперь проверим конверсию пользователей, которые совершили целевое действие, но при этом не добавили объявления в избранное.

In [73]:
# вычтем из чиала всех пользователей тех, кто совершил favorites_add, это будет второе значение trial
no_fave_users = data.query('user_id not in @fave_users')['user_id'].unique()
display(len(no_fave_users)) 
# а теперь выберем из их числа тех, кто совершил целевое действие - это будет второе значение sucess
no_fave_contacts_users = data.query('user_id in @no_fave_users and event_name == "contacts_show"')['user_id'].unique()
display(len(no_fave_contacts_users)) 
print('Конверсия в целевое действие составила {}%'.format(round(len(no_fave_contacts_users)/len(no_fave_users)*100, 1)))
3942
845
Конверсия в целевое действие составила 21.4%

Применим статистический тест для проверки.

In [74]:
# применим нашу функцию, чтобы сравнить группы
successes = np.array([len(fave_contacts_users), len(no_fave_contacts_users)])
trials = np.array([len(fave_users), len(no_fave_users)])
equal(successes, trials)
p-значение:  1.3455903058456897e-13
Отвергаем нулевую гипотезу: между долями есть значимая разница.

Видим что и вторая гипотеза подтвердилась: с большой долей вероятности пользователи, добавившие в избранное объявление, лучше конвертируются в целевое действие.

Содержание

Вывод¶

Мы изучили предоставленные заказчиком данные о логах мобильного приложения.
После обработки данных мы установили что располагаем данными с 7 октября 2019 по 3 ноября 2019 то есть примерно 4 недели. Данные полные.

События, совершаемые пользователями, складываются в несколько характерных сценариев, которые либо заканчиваются целевым событием - просмотром контактов, либо - возвращением к просмотру рекомендаций.
Вот самые распространенные сценарии:

  • просмотр рекомендаций, просмотр контактов
  • карта, просмотр рекомендаций, просмотр контактов
  • поиск, просмотр фото, просмотр контактов

  • поиск, карта, просмотр рекомендаций

  • поиск, карта, открытие карточки объявления, просмотр рекомендаций
  • карта, открытие карточки объявления, просмотр рекомендаций

В исследовании мы проиллюстрировани данные сценарии воронками.

Для того, чтобы проследить корреляцию с целевым действием, мы построили тепловую карту, которая еще раз продемонстрировала, что бОльшая часть людей просто приходят посмотреть картинки, и до целевого действия не доходят. Показ рекомендаций по геолокации помогает пользователем найти то что нужно и повышает конверсию.

Мы измерили время между распространенными парами событий в логе. На основании полученных данных можно предположить, что скорее всего алгоритм приложения показывает рекомендации рядом при просмотре карты. И что поиск часто не приводит к тому, что пользователь находит что-то что ему нужно, и как следствие - он даже не заходит в карточку контакта. Так же мы установили что, если пользователь добавил объявление в избранное, он с большой долей вероятности совршит целевое действие.

Далее мы проанализировали основные бизнес-метрики и выяснили:

  • среднее количество посетитиелей сайта в день: 279 человек
  • среднее количество посетитиелей сайта в неделю: 1382 человека
  • в среднем конверсия в целевое действие составила 18.35%

Так же мы построили тепловую карту и линейный график удержания, и выяснили что в данных нет каких-либо всплесков деятельности, что в общем логично, потому что мы не рассматриваем тестирование какой-либо фичи или какие-либо события, которые могли бы вызвать всплески в данных.

Мы проверили гипотезу 1: одни пользователи совершают действия tips_show и tips_click, другие — только tips_show. Конверсия в просмотры контактов различается у этих двух групп. Гипотеза подтвердилась: пользователи кликающие объявление лучше конвертируются в просмотр контактов.

Мы так же проверили гипотезу 2: пользователи, совершившие действие favorites_add лучше конвертируются в целевое действие, чем те у кого его нет, и она тоже подтвердилась, действительно, добавление в избранное значительно повышает вероятность просмотра контактов.

На основе проведенного исследования можно предложить разработчикам следующие рекомендации по улучшению приложения:

  1. Всего 18% из тех кто смотрит рекомендации доходит до целевого действия, что соответствует общей конверсии приложения - показ рекомендаций никак не стимулирует пользователей к покупке, скорее всего его механизм не отработан и его нужно исправить.
  2. Поскольку поиск объявлений по геолокации повышает конверсию, можно предложить соответствующим образом настроить механизм рекомендаций, чтобы пользователю показывались объявления поблизости.
  3. Поскольку добавление в избранное так же повышает конверсию, можно сделать напоминания или вывести избранное в более удобный доступ на главное странице.
  4. Мы так же установили что пользователи чаще скачивают пришложение и начинают им пользоваться в выходные дни, стоит поработать над удержанием, например добавив рекламу или интересные акции, которые смогут увлечь пользователей и создать ага-момент.

Дополнительно можно рассмотреть разницу в конверсии источников.